Skip to content

Bring the publisher handler back under the complexity law, and report why the law had no teeth - #256

Merged
Antawari merged 5 commits into
mainfrom
catrina/enforce-the-complexity-law
Jul 28, 2026
Merged

Bring the publisher handler back under the complexity law, and report why the law had no teeth#256
Antawari merged 5 commits into
mainfrom
catrina/enforce-the-complexity-law

Conversation

@Antawari

Copy link
Copy Markdown
Contributor

The refactor in this PR is the smallest thing in it. Four findings come first, because they are what the refactor uncovered and they are worth more than it.


1. A green complexipy run silently empties the snapshot to [] and exits 0

The 20-entry debt register can be destroyed by a passing run over any clean subtree. Control rod, A -> B -> A, run in this worktree:

A    md5 ce1dbdc0ff983e828e4a9ba69998fe67   20 entries
     $ complexipy src/bonfire/xp          # a clean subtree; exit 0
B    md5 d751713988987e9331980363e24189ce    0 entries   <- the whole register, gone
     $ git checkout complexipy-snapshot.json
A'   md5 ce1dbdc0ff983e828e4a9ba69998fe67   20 entries   <- byte-identical restore

A red run leaves it untouched (verified: md5 unchanged after a failing full-src run). Only success destroys it, and it exits 0 while doing so.

This is not theoretical. It fired a second time, unprompted, while I was verifying my own fix: a single complexipy src/bonfire/handlers/bard.py to confirm the handler was clean wiped the register again. Any developer who fixes one file and checks their work triggers it.

The kit's own docs/tool-spikes.md states "The snapshot does NOT auto-shrink." That was written against complexipy 5.5.0. The pinned battery is 5.6.0, where it is false. A register that a passing run can wipe is not a register.

2. CI does not run the complexity gate at all

.github/workflows/quality.yml pins the shared kit at adaa4ebf429fd9ba49de6520d4636e0f8604c71d. Proved three independent ways:

  • The pinned YAML. 193 lines listing its steps by hand: ruff check, ruff format, cf-sticky-check, cf-file-budget, cf-mirror-check, cf-recursion-check, cf-exemptions, mypy-through-baseline, pytest. No complexipy step. No cf-import-contract step. The only occurrence of the word is a comment on the install step, "[dev] carries ruff/mypy/mypy-baseline/pytest/complexipy". CI installs the complexity gauge and never invokes it.
  • A real run log. gh run view 30366566328 --log (quality-gate, push to main) contains exactly 17 ##[group]Run ... step headers. Every one matches the list above. None is complexipy.
  • Kit ancestry. The pin is an ancestor of the kit's head, and the commits that add the missing gates are its descendants: e842029 "...gate complexipy...", 3963ce5 "cf-import-contract joins the reusable quality gate", 2f5a4d5 "rewire quality-gate.yml to one cf-gate step".

A one-line SHA bump lights up complexipy and cf-import-contract together. That file is outside this lane's scope and is untouched here.

Worth stating plainly: this PR, which fixes a complexity violation, will be graded by a CI that does not measure complexity.

3. Even wired, the gate grades at 15, not at the repo's law of 10

cf-gate invokes complexipy <source_root> with no --max-complexity-allowed, so it inherits complexipy's default of 15. Measured consequence in this one package: three functions sit over the law and under the gate and would pass a fully-wired CI silently.

function CC vs law (10) vs gate (15)
steward.py::StewardHandler::handle 15 over passes
sage_correction_bounce.py::...::_run_correction_cycle 14 over passes
preflight_pytest.py::parse_pytest_junit_xml 11 over passes

This is why the drift was reported as two functions. There are five un-grandfathered violations in src/bonfire/handlers, not two -- the other three were invisible at threshold 15.

4. The registers structurally forbid the refactoring the law requires

Every handler file holding an un-grandfathered violation also carries a suppression that exemptions.json pinned by line number:

bard.py:282   wizard.py:492   steward.py:166
sage_correction_bounce.py:320/488/539
preflight_pytest.py:321    architect.py:72/214

Refactoring any of those functions shifts a pinned line and turns cf-exemptions red. The register that records accepted debt structurally forbids the refactoring that would retire it. That is the mechanism by which this debt never moves, and it explains the grandfather list better than any story about priorities.

This PR re-anchors only its own row, to a symbol. The other eight are still line-pinned.


The refactor

BardHandler.handle measured 19 against a limit of 10 and was never in complexipy-snapshot.json -- drift, not grandfathered debt, landed because nothing was checking.

Three behaviour-preserving extractions. Every function created is measured, so the branches are removed rather than relocated:

function before after
BardHandler::handle 19 7
BardHandler::_create_branch (new) -- 6
_partial_metadata (new) -- 3
_failed (new) -- 0
_slugify_task, __init__ 1, 0 1, 0 (untouched)

complexipy src/bonfire/handlers/bard.py --max-complexity-allowed 10 reports "All functions are within the allowed complexity."

No branch was deleted as unreachable. No branch was moved into a helper that is itself over the limit.

Control rod: the suite is the control, and its counts do not move

BEFORE   5746 passed, 3 skipped, 37 xfailed, 20 xpassed, 8 warnings
AFTER    5746 passed, 3 skipped, 37 xfailed, 20 xpassed, 8 warnings

Every count identical. mypy adds 0 new errors over the baseline.

Gate board

bare 7bddd71 this branch
ruff-check, ruff-format, cf-sticky-check PASS PASS
cf-file-budget, cf-mirror-check, cf-recursion-check PASS PASS
cf-exemptions, cf-import-contract, pytest PASS PASS
mypy FAIL FAIL
complexipy FAIL FAIL

Identical to bare main -- this branch adds no red. Both remaining reds are pre-existing on 7bddd71 and are stated honestly rather than claimed as wins:

  • mypy reports new: 0, unresolved: 54 -- baseline entries that no longer reproduce, not new errors. It is red on bare main locally while CI is green on that same commit, so it is an artefact of the local kit checkout being newer than the pinned one. Not mine, not diagnosed here.
  • complexipy is still red on wizard.py::WizardHandler::handle (17), left untouched on purpose.

Why wizard.py is untouched

Blocked three ways before the first edit: 499 lines against a 500-line new-file cap, in a package that had zero budget headroom, with its own suppression pinned at line 492. The prior handlers raise already recorded that the next change to it must split the module rather than compress it; a split creates a new file that draws against the same package total. It needs its own lane and its own budget decision.

architect.py::ArchitectHandler::handle (28) is grandfathered and was left alone. Nothing fell out of this work that reduced it.


The two register edits, surfaced not smuggled

exemptions.json -- a pointer repair, not a raise. bard.py's BLE001 pin moved from "282" to "BardHandler.handle". The suppression never moved; the lines above it did. A corrected number would drift again on the very next insertion, which is the defect itself re-committed, so this anchors to the symbol. The matcher already accepts either form (symbol_or_line == str(line) or symbol_or_line == symbol), and the symbol was computed with the gate's own _symbol_spans/_enclosing_symbol and confirmed unique in the file so it cannot swallow a second suppression. Diff is one line each way; entries 52, frozen_count 52, both unchanged; no escaped em-dashes.

file-budget.json -- src/bonfire/handlers 3250 to 3280, 30 lines. The package measured exactly 3250 against a ceiling of 3250: zero headroom, so no behaviour-preserving complexity refactor could land in it at all. The structured package_raises entry carries what the lines buy and the six alternatives rejected, including: adding the function to complexipy-snapshot.json (the exact move this programme exists to end); the 7-line minimum variant (measured for complexity and lines but never run against the suite -- an unproven refactor is not cheaper, it is unmeasured); deleting comments to buy the lines back (gaming, and futile since the gauge measures max(physical, statements)); and a purpose entry for a split file, which would have made the package total fall but drops a live file out of the ratchet permanently.

Three commits, deliberately separate: the refactor, the re-anchor, the raise.

No entry was added to complexipy-snapshot.json. It is byte-identical to 7bddd71 (md5 ce1dbdc0ff983e828e4a9ba69998fe67).


Recommendations -- reported, not acted on

  1. Bump the workflow SHA so the gate runs at all.
  2. Pin --max-complexity-allowed 10 so it grades the law rather than complexipy's default.
  3. Fence the 5.6.0 auto-rewrite, and correct docs/tool-spikes.md, which documents the 5.5.0 behaviour as current.
  4. Re-anchor the remaining eight line-pinned exemptions to symbols, or refactoring stays blocked wherever debt sits.
  5. Rule on the snapshot itself. Booted once, on 2026-06-22, by 986fc03 -- "Grandfathers existing type errors (43) and complexity at gate-mount... Ratchet forward." Stated intent, named owner. But no entry has ever left the list in the five weeks since, there is no doc, comment or README in this repo describing how one leaves (grepped docs/, CLAUDE.md, CONTRIBUTING.md, README.md: zero hits for complexipy), and there is no mechanism: cf-gate runs complexipy without --snapshot-create and CI never commits, so improvements are never locked in. Booted as a debt register with a plan; operating as an oubliette.

What this PR does not prove

  • CI's behaviour with the gate wired. Proved the pinned workflow does not invoke complexipy; did not run a bumped SHA.
  • The local battery is not CI's battery. The venv's cf_quality is editable-installed from a kit checkout newer than the pin. The finding above rests on the pinned YAML, a real run log and kit ancestry, not on this -- but the local gate board is not byte-for-byte what CI prints.
  • The 7-line minimum variant is measured for complexity and line count only, never run against the suite.
  • The three sub-15 violations (steward 15, _run_correction_cycle 14, parse_pytest_junit_xml 11) were measured and reported, never touched.
  • Why mypy's baseline has 54 unresolved entries. Pre-existing, out of scope, flagged not fixed.
  • Required-status-check mounting. Whether quality-gate is actually required in branch protection is invisible from a run log. A gate that runs and is not required still merges red.

One observation outside this lane's scope, stated once: exemptions.json carries internal tracker IDs in its approver fields on a public repo. Pre-existing across many rows; fixing it would blow up the diff and race two live lanes.

🤖 Generated with Claude Code

Antawari and others added 5 commits July 28, 2026 08:59
BardHandler.handle measured 19 against a limit of 10 and was never in
complexipy-snapshot.json: drift, not grandfathered debt, and it landed
because nothing was checking.

Three behaviour-preserving extractions, each one measured so the branches
are removed rather than relocated:

  BardHandler._create_branch   6  the branch-creation try/except and its
                                  'already exists' probe
  _partial_metadata            3  the salvage-what-we-learned map on the
                                  throw path
  _failed                      0  the FAILED envelope that four refusal
                                  paths each built by hand
  BardHandler.handle      19 -> 7

No branch was deleted as unreachable and none was moved into a helper
that is itself over the limit.

The suite is the control and its counts are unchanged on both sides:
5746 passed, 3 skipped, 37 xfailed, 20 xpassed. mypy adds no new errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
exemptions.json pinned bard.py's BLE001 to line 282. The suppression did
not move; the lines above it did, and the gate went red reading
UNREGISTERED_SUPPRESSION against a suppression that was registered.

A corrected number drifts again on the next insertion, so this anchors to
BardHandler.handle instead. The matcher already accepts either form
(symbol_or_line == str(line) or symbol_or_line == symbol) and the symbol
is unique in the file, so it cannot swallow a second suppression.

Computed with the gate's own _symbol_spans/_enclosing_symbol rather than
by eye. A pointer repair, not a raise: 52 entries and frozen_count 52,
both unchanged, and the diff is one line each way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
src/bonfire/handlers measured EXACTLY 3250 against a ceiling of 3250, so
the package had zero headroom and no behaviour-preserving complexity
refactor could land in it at all.

The structured entry records what the 30 lines buy, the six alternatives
rejected and why, and the finding underneath: every handler file holding
an un-grandfathered violation also carries a suppression pinned by line
number, so the register that records accepted debt structurally forbids
the refactoring that would retire it.

Surfaced in the PR body and in the lane report, not taken silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…omplexity-law

# Conflicts:
#	file-budget.json
…omplexity-law

# Conflicts:
#	file-budget.json
@Antawari
Antawari merged commit 03dc019 into main Jul 28, 2026
4 checks passed
@Antawari
Antawari deleted the catrina/enforce-the-complexity-law branch July 28, 2026 17:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant